CI/CD Pipelines
This guide outlines the key principles and practices for managing an Azure DevOps pipeline that implements conditional builds, multi-service Docker images, Azure Container Registry (ACR) push, and Azure Container Apps deployment via CLI, on a cloud-native infrastructure.
Modular & Conditional Execution
Best Practices:
- Use
parametersfor conditional builds (e.g.,buildFrontend,buildBackend,buildWorker). - Enables pipeline reuse with minimal changes and full control over what runs in each execution.
- Parameter names should be clear and scoped per service.
Docker Build & Push Strategy
Best Practices:
- Organize
Dockerfilepaths and context as pipeline variables. - Separating
build,tag, andpushimproves readability and debugging. - Use a unique build tag per run (
$(Build.BuildId)) for traceability. - Use
DockerInstallertask only if the native Docker runtime is missing (e.g., on Microsoft-hosted agents).
Image Tagging & Registry Publishing
Best Practices:
- Push to Azure Container Registry only after a successful build.
- Use separate repositories per service role.
- Avoid
latesttags — use versioned or build ID–based tags for reproducibility.
Deployment to Azure Container Apps
Best Practices:
- Use
AzureContainerApps@1task for simple image updates. - For full environment control, use
AzureCLI@2withaz containerapp update --set-env-vars. - Do not include credentials in YAML — prefer using Key Vault references or pipeline secrets.
Environment Variables (Env Vars)
Best Practices:
- Separate by role (frontend/backend/worker).
- Avoid hardcoded secrets (e.g.,
SECRET_KEY,DB_PASSWORD). Instead, use:- Azure Key Vault references
- Pipeline secrets
- YAML templates with environment-specific overrides
- Critical info such as tokens, credentials, and URLs should be isolated in variable groups or external secret stores.
Stage Structure & Flow
Best Practices:
- Clear separation between
BuildandDeploystages. - Deployments should depend on the successful completion of the
Buildstage (dependsOn: Build). - Use an agent pool with a clear label and consistent configuration (e.g.,
VMSSAgents).
Secrets & Security
Best Practices:
- All sensitive values must be passed via pipeline secrets.
- Avoid hardcoded credentials inline — not even for testing.
- Where CLI updates occur, use authenticated identities, not exposed secrets.
Documentation & Maintainability
Best Practices:
- Each section should have comments explaining its purpose (e.g.,
# Build Frontend,# Deploy Celery Beat). - Parameters should include a
displayName, not just internalname, to improve UI usability. - Maintain a README or internal wiki with usage instructions and input options for the pipeline.
Example Azure DevOps Pipeline – Containerized App Deployment (Dummy Example)
trigger: none # Only manual runs
parameters:
- name: buildFrontend
type: boolean
default: false
- name: buildBackend
type: boolean
default: false
- name: buildWorker
type: boolean
default: false
variables:
tag: "$(Build.BuildId)"
dockerRegistryServiceConnection: "myContainerRegistryConnection"
containerRegistry: "myregistry.azurecr.io"
frontendImage: "frontend-app"
backendImage: "backend-api"
workerImage: "background-worker"
dockerfileFrontend: "$(Build.SourcesDirectory)/frontend/Dockerfile"
dockerfileBackend: "$(Build.SourcesDirectory)/backend/Dockerfile"
dockerfileWorker: "$(Build.SourcesDirectory)/worker/Dockerfile"
contextFrontend: "$(Build.SourcesDirectory)/frontend"
contextBackend: "$(Build.SourcesDirectory)/backend"
contextWorker: "$(Build.SourcesDirectory)/worker"
azureSubscription: "My-Azure-Sub"
resourceGroup: "my-prod-rg"
containerAppFrontend: "frontend-app"
containerAppBackend: "backend-api"
containerAppWorker: "worker-job"
stages:
- stage: BuildAndPush
displayName: "Build and Push Images"
jobs:
- job: Build
pool:
vmImage: "ubuntu-latest"
steps:
- task: DockerInstaller@0
inputs:
dockerVersion: "20.10.7"
- task: Docker@2
displayName: "Login to ACR"
inputs:
command: login
containerRegistry: "$(dockerRegistryServiceConnection)"
# Build Frontend
- ${{ if eq(parameters.buildFrontend, true) }}:
- script: |
docker build -t $(frontendImage):$(tag) -f $(dockerfileFrontend) $(contextFrontend)
docker tag $(frontendImage):$(tag) $(containerRegistry)/$(frontendImage):$(tag)
docker push $(containerRegistry)/$(frontendImage):$(tag)
displayName: "Build & Push Frontend"
# Build Backend
- ${{ if eq(parameters.buildBackend, true) }}:
- script: |
docker build -t $(backendImage):$(tag) -f $(dockerfileBackend) $(contextBackend)
docker tag $(backendImage):$(tag) $(containerRegistry)/$(backendImage):$(tag)
docker push $(containerRegistry)/$(backendImage):$(tag)
displayName: "Build & Push Backend"
# Build Worker
- ${{ if eq(parameters.buildWorker, true) }}:
- script: |
docker build -t $(workerImage):$(tag) -f $(dockerfileWorker) $(contextWorker)
docker tag $(workerImage):$(tag) $(containerRegistry)/$(workerImage):$(tag)
docker push $(containerRegistry)/$(workerImage):$(tag)
displayName: "Build & Push Worker"
- stage: Deploy
displayName: "Deploy Container Apps"
dependsOn: BuildAndPush
jobs:
- job: Deploy
pool:
vmImage: "ubuntu-latest"
steps:
# Deploy Frontend
- ${{ if eq(parameters.buildFrontend, true) }}:
- task: AzureContainerApps@1
inputs:
azureSubscription: "$(azureSubscription)"
resourceGroup: "$(resourceGroup)"
containerAppName: "$(containerAppFrontend)"
imageToDeploy: "$(containerRegistry)/$(frontendImage):$(tag)"
# Deploy Backend
- ${{ if eq(parameters.buildBackend, true) }}:
- task: AzureContainerApps@1
inputs:
azureSubscription: "$(azureSubscription)"
resourceGroup: "$(resourceGroup)"
containerAppName: "$(containerAppBackend)"
imageToDeploy: "$(containerRegistry)/$(backendImage):$(tag)"
# Deploy Worker
- ${{ if eq(parameters.buildWorker, true) }}:
- task: AzureContainerApps@1
inputs:
azureSubscription: "$(azureSubscription)"
resourceGroup: "$(resourceGroup)"
containerAppName: "$(containerAppWorker)"
imageToDeploy: "$(containerRegistry)/$(workerImage):$(tag)"
